page.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import { useSession } from "next-auth/react";
  4. import { redirect, useRouter } from "next/navigation";
  5. import Link from "next/link";
  6. import AuthenticatedLayout from "@/components/AuthenticatedLayout";
  7. import { AppointmentStatusBadge } from "@/components/appointments/AppointmentStatusBadge";
  8. import { Button } from "@/components/ui/button";
  9. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
  10. import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
  11. import { Separator } from "@/components/ui/separator";
  12. import {
  13. Dialog,
  14. DialogContent,
  15. DialogDescription,
  16. DialogFooter,
  17. DialogHeader,
  18. DialogTitle,
  19. } from "@/components/ui/dialog";
  20. import { Textarea } from "@/components/ui/textarea";
  21. import { Label } from "@/components/ui/label";
  22. import { ApproveAppointmentModal } from "@/components/appointments/ApproveAppointmentModal";
  23. import {
  24. Calendar,
  25. Clock,
  26. User,
  27. FileText,
  28. Video,
  29. CheckCircle2,
  30. XCircle,
  31. ArrowLeft,
  32. Loader2,
  33. AlertCircle,
  34. } from "lucide-react";
  35. import { format } from "date-fns";
  36. import { es } from "date-fns/locale";
  37. import { toast } from "sonner";
  38. import type { Appointment } from "@/types/appointments";
  39. import { canJoinMeeting, getAppointmentTimeStatus } from "@/utils/appointments";
  40. interface PageProps {
  41. params: Promise<{ id: string }>;
  42. }
  43. export default function AppointmentDetailPage({ params }: PageProps) {
  44. const router = useRouter();
  45. const { data: session, status } = useSession();
  46. const [appointment, setAppointment] = useState<Appointment | null>(null);
  47. const [loading, setLoading] = useState(true);
  48. const [approveDialog, setApproveDialog] = useState(false);
  49. const [rejectDialog, setRejectDialog] = useState(false);
  50. const [motivoRechazo, setMotivoRechazo] = useState("");
  51. const [actionLoading, setActionLoading] = useState(false);
  52. const [appointmentId, setAppointmentId] = useState<string>("");
  53. useEffect(() => {
  54. const loadParams = async () => {
  55. const resolvedParams = await params;
  56. setAppointmentId(resolvedParams.id);
  57. };
  58. loadParams();
  59. }, [params]);
  60. useEffect(() => {
  61. if (!appointmentId) return;
  62. const fetchAppointment = async () => {
  63. try {
  64. const response = await fetch(`/api/appointments/${appointmentId}`);
  65. if (!response.ok) {
  66. throw new Error("No se pudo cargar la cita");
  67. }
  68. const data: Appointment = await response.json();
  69. setAppointment(data);
  70. } catch (error) {
  71. toast.error("Error al cargar la cita");
  72. console.error(error);
  73. } finally {
  74. setLoading(false);
  75. }
  76. };
  77. fetchAppointment();
  78. }, [appointmentId]);
  79. if (status === "loading" || loading) {
  80. return (
  81. <AuthenticatedLayout>
  82. <div className="flex items-center justify-center min-h-screen">
  83. <Loader2 className="h-8 w-8 animate-spin" />
  84. </div>
  85. </AuthenticatedLayout>
  86. );
  87. }
  88. if (!session) {
  89. redirect("/auth/login");
  90. }
  91. if (!appointment) {
  92. return (
  93. <AuthenticatedLayout>
  94. <div className="container mx-auto px-4 py-6">
  95. <Card>
  96. <CardContent className="flex flex-col items-center justify-center py-12">
  97. <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
  98. <h3 className="text-lg font-semibold mb-2">Cita no encontrada</h3>
  99. <p className="text-muted-foreground mb-4">
  100. La cita que buscas no existe o no tienes permisos para verla.
  101. </p>
  102. <Button asChild>
  103. <Link href="/appointments">Volver a mis citas</Link>
  104. </Button>
  105. </CardContent>
  106. </Card>
  107. </div>
  108. </AuthenticatedLayout>
  109. );
  110. }
  111. const userRole = session.user.role as "PATIENT" | "DOCTOR" | "ADMIN";
  112. const isPatient = userRole === "PATIENT";
  113. const isDoctor = userRole === "DOCTOR";
  114. const otherUser = isPatient ? appointment.medico : appointment.paciente;
  115. const hasFecha = appointment.fechaSolicitada !== null;
  116. const fecha = hasFecha ? new Date(appointment.fechaSolicitada!) : null;
  117. const handleApprove = async (fechaSolicitada: Date, notas?: string) => {
  118. setActionLoading(true);
  119. try {
  120. const response = await fetch(`/api/appointments/${appointment.id}/approve`, {
  121. method: "POST",
  122. headers: {
  123. "Content-Type": "application/json",
  124. },
  125. body: JSON.stringify({
  126. fechaSolicitada: fechaSolicitada.toISOString(),
  127. notas,
  128. }),
  129. });
  130. if (!response.ok) {
  131. const error = await response.json();
  132. throw new Error(error.error || "Error al aprobar la cita");
  133. }
  134. const updated: Appointment = await response.json();
  135. setAppointment(updated);
  136. setApproveDialog(false);
  137. toast.success("Cita aprobada exitosamente");
  138. } catch (error) {
  139. toast.error(error instanceof Error ? error.message : "Error al aprobar la cita");
  140. console.error(error);
  141. } finally {
  142. setActionLoading(false);
  143. }
  144. };
  145. const handleRejectConfirm = async () => {
  146. if (!motivoRechazo.trim()) return;
  147. setActionLoading(true);
  148. try {
  149. const response = await fetch(`/api/appointments/${appointment.id}/reject`, {
  150. method: "POST",
  151. headers: { "Content-Type": "application/json" },
  152. body: JSON.stringify({ motivoRechazo }),
  153. });
  154. if (!response.ok) throw new Error("Error al rechazar la cita");
  155. const updated: Appointment = await response.json();
  156. setAppointment(updated);
  157. setRejectDialog(false);
  158. setMotivoRechazo("");
  159. toast.success("Cita rechazada");
  160. } catch (error) {
  161. toast.error("Error al rechazar la cita");
  162. console.error(error);
  163. } finally {
  164. setActionLoading(false);
  165. }
  166. };
  167. const handleCancel = async () => {
  168. setActionLoading(true);
  169. try {
  170. const response = await fetch(`/api/appointments/${appointment.id}`, {
  171. method: "DELETE",
  172. });
  173. if (!response.ok) throw new Error("Error al cancelar la cita");
  174. toast.success("Cita cancelada exitosamente");
  175. router.push("/appointments");
  176. } catch (error) {
  177. toast.error("Error al cancelar la cita");
  178. console.error(error);
  179. setActionLoading(false);
  180. }
  181. };
  182. const handleStartMeeting = async () => {
  183. setActionLoading(true);
  184. try {
  185. const response = await fetch(`/api/appointments/${appointment.id}/start-meeting`, {
  186. method: "POST",
  187. });
  188. if (!response.ok) {
  189. const error = await response.json();
  190. throw new Error(error.message || error.error || "No se puede iniciar la videollamada");
  191. }
  192. const data = await response.json();
  193. // Redirigir a la sala de Jitsi
  194. router.push(`/appointments/${appointment.id}/meet`);
  195. } catch (error) {
  196. toast.error(error instanceof Error ? error.message : "Error al iniciar videollamada");
  197. console.error(error);
  198. } finally {
  199. setActionLoading(false);
  200. }
  201. };
  202. const handleComplete = async () => {
  203. setActionLoading(true);
  204. try {
  205. const response = await fetch(`/api/appointments/${appointment.id}/complete`, {
  206. method: "POST",
  207. });
  208. if (!response.ok) throw new Error("Error al completar la cita");
  209. const updated: Appointment = await response.json();
  210. setAppointment(updated);
  211. toast.success("Cita marcada como completada");
  212. } catch (error) {
  213. toast.error("Error al completar la cita");
  214. console.error(error);
  215. } finally {
  216. setActionLoading(false);
  217. }
  218. };
  219. return (
  220. <AuthenticatedLayout>
  221. <div className="container mx-auto px-4 py-6 max-w-4xl">
  222. {/* Back Button */}
  223. <Button
  224. variant="ghost"
  225. className="mb-4"
  226. onClick={() => router.back()}
  227. >
  228. <ArrowLeft className="h-4 w-4 mr-2" />
  229. Volver
  230. </Button>
  231. {/* Header Card */}
  232. <Card className="mb-6">
  233. <CardHeader>
  234. <div className="flex items-center justify-between">
  235. <div className="flex items-center gap-4">
  236. {otherUser && (
  237. <Avatar className="h-16 w-16">
  238. <AvatarImage src={otherUser.profileImage || undefined} />
  239. <AvatarFallback className="text-lg">
  240. {otherUser.name[0]}{otherUser.lastname[0]}
  241. </AvatarFallback>
  242. </Avatar>
  243. )}
  244. <div>
  245. <CardTitle className="text-2xl">
  246. {otherUser
  247. ? `${otherUser.name} ${otherUser.lastname}`
  248. : isDoctor
  249. ? "Sin asignar"
  250. : "Médico por asignar"}
  251. </CardTitle>
  252. <CardDescription>
  253. {isPatient ? "Médico asignado" : "Paciente"}
  254. </CardDescription>
  255. </div>
  256. </div>
  257. <AppointmentStatusBadge status={appointment.estado} />
  258. </div>
  259. </CardHeader>
  260. </Card>
  261. {/* Details Card */}
  262. <Card className="mb-6">
  263. <CardHeader>
  264. <CardTitle>Detalles de la Cita</CardTitle>
  265. </CardHeader>
  266. <CardContent className="space-y-4">
  267. {hasFecha && fecha ? (
  268. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  269. <div className="flex items-start gap-3">
  270. <Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
  271. <div>
  272. <p className="text-sm font-medium">Fecha</p>
  273. <p className="text-sm text-muted-foreground">
  274. {format(fecha, "PPP", { locale: es })}
  275. </p>
  276. </div>
  277. </div>
  278. <div className="flex items-start gap-3">
  279. <Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
  280. <div>
  281. <p className="text-sm font-medium">Hora</p>
  282. <p className="text-sm text-muted-foreground">
  283. {format(fecha, "p", { locale: es })}
  284. </p>
  285. </div>
  286. </div>
  287. </div>
  288. ) : (
  289. <div className="bg-muted/50 p-4 rounded-lg">
  290. <div className="flex items-start gap-3">
  291. <Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
  292. <div>
  293. <p className="text-sm font-medium">Fecha y hora</p>
  294. <p className="text-sm text-muted-foreground italic">
  295. {appointment.estado === "PENDIENTE"
  296. ? "Pendiente de asignación por el médico"
  297. : "No asignada"}
  298. </p>
  299. </div>
  300. </div>
  301. </div>
  302. )}
  303. <Separator />
  304. <div className="flex items-start gap-3">
  305. <FileText className="h-5 w-5 text-muted-foreground mt-0.5" />
  306. <div className="flex-1">
  307. <p className="text-sm font-medium mb-1">Motivo de consulta</p>
  308. <p className="text-sm text-muted-foreground">
  309. {appointment.motivoConsulta}
  310. </p>
  311. </div>
  312. </div>
  313. {appointment.motivoRechazo && (
  314. <>
  315. <Separator />
  316. <div className="bg-destructive/10 p-4 rounded-lg">
  317. <div className="flex items-start gap-3">
  318. <XCircle className="h-5 w-5 text-destructive mt-0.5" />
  319. <div className="flex-1">
  320. <p className="text-sm font-medium text-destructive mb-1">
  321. Motivo de rechazo
  322. </p>
  323. <p className="text-sm text-muted-foreground">
  324. {appointment.motivoRechazo}
  325. </p>
  326. </div>
  327. </div>
  328. </div>
  329. </>
  330. )}
  331. {/* Solo mostrar sala si NO está completada */}
  332. {appointment.roomName && appointment.estado !== "COMPLETADA" && (
  333. <>
  334. <Separator />
  335. <div className="bg-primary/10 p-4 rounded-lg">
  336. <div className="flex items-start gap-3">
  337. <Video className="h-5 w-5 text-primary mt-0.5" />
  338. <div className="flex-1">
  339. <p className="text-sm font-medium mb-1">Sala de videollamada</p>
  340. <p className="text-sm text-muted-foreground mb-3">
  341. La sala está lista. Puedes unirte cuando llegue la hora de la cita.
  342. </p>
  343. <Button asChild size="sm">
  344. <Link href={`/appointments/${appointment.id}/meet`}>
  345. <Video className="h-4 w-4 mr-2" />
  346. Unirse a la consulta
  347. </Link>
  348. </Button>
  349. </div>
  350. </div>
  351. </div>
  352. </>
  353. )}
  354. {appointment.notasGuardadas && appointment.notasConsulta && (
  355. <>
  356. <Separator />
  357. <div className="bg-green-50 dark:bg-green-950 p-4 rounded-lg">
  358. <div className="flex items-start gap-3">
  359. <FileText className="h-5 w-5 text-green-700 dark:text-green-400 mt-0.5" />
  360. <div className="flex-1">
  361. <p className="text-sm font-medium text-green-900 dark:text-green-100 mb-1">
  362. Notas de la Consulta
  363. </p>
  364. {appointment.notasGuardadasAt && (
  365. <p className="text-xs text-green-700 dark:text-green-300 mb-2">
  366. Guardadas el {format(new Date(appointment.notasGuardadasAt), "d 'de' MMMM 'a las' HH:mm", { locale: es })}
  367. </p>
  368. )}
  369. <div className="text-sm text-green-900 dark:text-green-100 whitespace-pre-wrap bg-white/50 dark:bg-black/20 p-3 rounded">
  370. {appointment.notasConsulta}
  371. </div>
  372. </div>
  373. </div>
  374. </div>
  375. </>
  376. )}
  377. </CardContent>
  378. </Card>
  379. {/* Actions Card */}
  380. <Card>
  381. <CardHeader>
  382. <CardTitle>Acciones</CardTitle>
  383. </CardHeader>
  384. <CardContent>
  385. <div className="flex flex-wrap gap-3">
  386. {isDoctor && appointment.estado === "PENDIENTE" && (
  387. <>
  388. <Button
  389. onClick={() => setApproveDialog(true)}
  390. disabled={actionLoading}
  391. className="flex-1 min-w-[150px]"
  392. >
  393. {actionLoading ? (
  394. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  395. ) : (
  396. <CheckCircle2 className="h-4 w-4 mr-2" />
  397. )}
  398. Aprobar Cita
  399. </Button>
  400. <Button
  401. onClick={() => setRejectDialog(true)}
  402. variant="destructive"
  403. disabled={actionLoading}
  404. className="flex-1 min-w-[150px]"
  405. >
  406. <XCircle className="h-4 w-4 mr-2" />
  407. Rechazar Cita
  408. </Button>
  409. </>
  410. )}
  411. {isDoctor && appointment.estado === "APROBADA" && (
  412. <>
  413. {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
  414. <Button
  415. onClick={handleStartMeeting}
  416. disabled={actionLoading}
  417. className="flex-1 min-w-[150px]"
  418. >
  419. {actionLoading ? (
  420. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  421. ) : (
  422. <Video className="h-4 w-4 mr-2" />
  423. )}
  424. Unirse a Videollamada
  425. </Button>
  426. ) : (
  427. <Button
  428. disabled
  429. variant="outline"
  430. className="flex-1 min-w-[150px]"
  431. >
  432. <Clock className="h-4 w-4 mr-2" />
  433. {getAppointmentTimeStatus(appointment.fechaSolicitada)}
  434. </Button>
  435. )}
  436. <Button
  437. onClick={handleComplete}
  438. disabled={actionLoading}
  439. variant="outline"
  440. className="flex-1 min-w-[150px]"
  441. >
  442. {actionLoading ? (
  443. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  444. ) : (
  445. <CheckCircle2 className="h-4 w-4 mr-2" />
  446. )}
  447. Marcar como Completada
  448. </Button>
  449. </>
  450. )}
  451. {isPatient && appointment.estado === "PENDIENTE" && (
  452. <Button
  453. onClick={handleCancel}
  454. variant="outline"
  455. disabled={actionLoading}
  456. className="flex-1 min-w-[150px]"
  457. >
  458. {actionLoading ? (
  459. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  460. ) : (
  461. <XCircle className="h-4 w-4 mr-2" />
  462. )}
  463. Cancelar Cita
  464. </Button>
  465. )}
  466. {isPatient && appointment.estado === "APROBADA" && (
  467. <>
  468. {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
  469. <Button
  470. onClick={handleStartMeeting}
  471. disabled={actionLoading}
  472. className="flex-1 min-w-[150px]"
  473. >
  474. {actionLoading ? (
  475. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  476. ) : (
  477. <Video className="h-4 w-4 mr-2" />
  478. )}
  479. Unirse a Videollamada
  480. </Button>
  481. ) : (
  482. <Button
  483. disabled
  484. variant="outline"
  485. className="flex-1 min-w-[150px]"
  486. >
  487. <Clock className="h-4 w-4 mr-2" />
  488. {getAppointmentTimeStatus(appointment.fechaSolicitada)}
  489. </Button>
  490. )}
  491. </>
  492. )}
  493. {/* Acciones para citas completadas */}
  494. {appointment.estado === "COMPLETADA" && (
  495. <>
  496. {appointment.notasGuardadas && appointment.notasConsulta ? (
  497. <div className="flex-1 min-w-[150px] bg-green-50 dark:bg-green-950 p-4 rounded-lg">
  498. <div className="flex items-center gap-2 mb-2">
  499. <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
  500. <p className="text-sm font-medium text-green-900 dark:text-green-100">
  501. Consulta Finalizada
  502. </p>
  503. </div>
  504. <p className="text-xs text-green-700 dark:text-green-300 mb-3">
  505. Las notas de la consulta están disponibles arriba
  506. </p>
  507. </div>
  508. ) : (
  509. <div className="flex-1 min-w-[150px] bg-muted p-4 rounded-lg">
  510. <div className="flex items-center gap-2 mb-2">
  511. <CheckCircle2 className="h-5 w-5 text-muted-foreground" />
  512. <p className="text-sm font-medium">
  513. Consulta Finalizada
  514. </p>
  515. </div>
  516. <p className="text-xs text-muted-foreground">
  517. Esta cita ha sido completada
  518. </p>
  519. </div>
  520. )}
  521. </>
  522. )}
  523. {/* Botón genérico de unirse (solo para APROBADA, no COMPLETADA) */}
  524. {appointment.estado === "APROBADA" && canJoinMeeting(appointment.fechaSolicitada).canJoin && (
  525. <Button asChild className="flex-1 min-w-[150px]">
  526. <Link href={`/appointments/${appointment.id}/meet`}>
  527. <Video className="h-4 w-4 mr-2" />
  528. Unirse a la Consulta
  529. </Link>
  530. </Button>
  531. )}
  532. </div>
  533. </CardContent>
  534. </Card>
  535. {/* Reject Dialog */}
  536. <Dialog open={rejectDialog} onOpenChange={setRejectDialog}>
  537. <DialogContent>
  538. <DialogHeader>
  539. <DialogTitle>Rechazar Cita</DialogTitle>
  540. <DialogDescription>
  541. Por favor proporciona un motivo para rechazar esta cita. El paciente recibirá esta información.
  542. </DialogDescription>
  543. </DialogHeader>
  544. <div className="space-y-2">
  545. <Label htmlFor="motivo">Motivo del rechazo</Label>
  546. <Textarea
  547. id="motivo"
  548. value={motivoRechazo}
  549. onChange={(e) => setMotivoRechazo(e.target.value)}
  550. placeholder="Ejemplo: No hay disponibilidad en esta fecha, por favor reagenda para la próxima semana..."
  551. rows={4}
  552. className="resize-none"
  553. />
  554. </div>
  555. <DialogFooter>
  556. <Button
  557. variant="outline"
  558. onClick={() => {
  559. setRejectDialog(false);
  560. setMotivoRechazo("");
  561. }}
  562. >
  563. Cancelar
  564. </Button>
  565. <Button
  566. variant="destructive"
  567. onClick={handleRejectConfirm}
  568. disabled={!motivoRechazo.trim() || actionLoading}
  569. >
  570. {actionLoading ? (
  571. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  572. ) : (
  573. <XCircle className="h-4 w-4 mr-2" />
  574. )}
  575. Rechazar Cita
  576. </Button>
  577. </DialogFooter>
  578. </DialogContent>
  579. </Dialog>
  580. {/* Approve Dialog */}
  581. <ApproveAppointmentModal
  582. open={approveDialog}
  583. onClose={() => setApproveDialog(false)}
  584. onConfirm={handleApprove}
  585. isLoading={actionLoading}
  586. />
  587. </div>
  588. </AuthenticatedLayout>
  589. );
  590. }